Skip to content

Add the Browser Control MCP server in TypeScript - #4

Merged
afif-reap merged 45 commits into
mainfrom
afif/ts-server
Sep 28, 2026
Merged

afif-reap merged 45 commits into
mainfrom
afif/ts-server

Conversation

@afif-reap

Copy link
Copy Markdown
Contributor

Ports the fast-chrome MCP server from Python to TypeScript, and packages it as @op1/browser-control (bin browser-control), so that setup is npx -y @op1/browser-control mcp and Node 24 is the only runtime requirement.

  • Server (src/server/**): the same 18 tools, schemas, limits, protocol 2 and bounded shutdown as the Python server. Every one of its 299 Python test functions is mapped in tests/server/parity/: 290 ported, 9 not ported, all of them approved removals. check-parity --complete runs in pnpm run check.
  • Intended changes: they are listed in docs/server/DESIGN.md. Portable cua-driver and Node resolution; packaged assets with stable, versioned copies under one state root (BROWSER_CONTROL_STATE_DIR); configurable unshared sites; no Direct account-pool lease; a per-process session fallback for clients that send no session ID; the Browser Control names; and a fixed ID for isolated profiles (mpodnojmjjafgogldgieimgbmfhhknbe).
  • CLI: mcp, install, doctor, config and the pool operator commands. Both native-host installers (npm, and the release zip) share one lock and refuse to replace each other's manifest without --force.
  • Skills: browser-control, onepassword-session and create-verification-skill, generic and client-agnostic.
  • CI and release: Node 24.

Verification (independent of the implementers):

  • Live test on the packed package: install, doctor, and a real Chrome for Testing session through a stdio MCP client (claim_browser, act_steps, wait_for, screenshot, release), with and without a session ID. Also covered: orphaned-lease recovery, installer conflicts and shell quoting, and proof that the user's real directories were untouched.
  • Static audits against the Python source. The findings from all three rounds are fixed or recorded as approved deviations.

The reviewer steps in site/ and store/ still describe the released 0.2.2 helper, which is under Web Store review. docs/RELEASE.md lists what to refresh before the next release.

Sets up @op1/browser-control: the server source and test layout, bundled
dependencies, a self-contained CLI and native-host build, and the parity
checker, so pnpm run check covers the server.

Ports opchrome.py as host-connection.ts, sites.py, and native_captures.py,
with Python-semantics helpers (urlsplit, ipaddress, IDNA 2003, str and json
behavior) checked against captured CPython and Pillow output. SQLite locks
replace fcntl.flock. runStdioServer implements the bounded shutdown; app,
cli, pool operator and clipboard guard are stubs for later slices.

docs/server/DESIGN.md records the design, the coordinator decisions (a fixed
isolated-extension key, no pool lease on paste_1password_field) and the
retirement of the opchrome name (D19): opchrome-* codes become
browser-control-*, OPZERO_CHROME_HOST_SOCKET becomes
BROWSER_CONTROL_HOST_SOCKET, and the wrapper is browser-control-host.
Merge the installed chrome-control skill with the repo's old skill into
skills/browser-control, the directory package.json ships. Describe setup
through npx @op1/browser-control install and doctor, the session-identity
fallback, and a whole-section act_steps example. Point native control at
the skill that cua-driver installs. Leave skills/chrome-control untouched.
The MCP instructions' "Load chrome-control" is compared as "Load
browser-control", and the server test harness reads
BROWSER_CONTROL_TEST_TMPDIR. DESIGN.md records the user's decision to rename
the skill, host and scripts on afif/browser-control-rename, the one merge
conflict in scripts/build.mjs, and removing the host-variable shim once that
branch is merged.
Ports clipboard_guard.py, onepassword.py and private_input.py as
private/clipboard-guard.ts, private/onepassword.ts, private/cua-mcp.ts and
private/private-input.ts, with their tests (test_clipboard_guard.py,
test_onepassword.py, test_private_input.py) mapped in
tests/server/parity/private.md.

The vault reader opens a private `cua-driver mcp` connection through the MCP
SDK stdio client per read, serializes reads with the bounded lock at
<state>/locks/onepassword.lock, and copies only inside the clipboard guard.
The guard trusts only an owner-owned, non-writable, executable regular file,
and buildClipboardGuard compiles the Swift source into
<state>/bin/clipboard-guard-<sha12>.

The private transfer no longer takes a Direct account-pool lease (C6, Q2).
PasteRequest carries the caller session instead, and paste refuses
fast-chrome-tab-not-owned unless it owns the tab. The pool-lease test is not
ported; new tests prove ownership is still required. The other deviations are
listed in the parity file.
Each lock attempt opened and closed its own descriptor on the lock file to
check it. Closing any descriptor drops every POSIX lock the process holds on
that file, so a second attempt in a process that already held the lock (a
second tab's pin, a status read beside a pin) silently released the first
holder's lock for other processes, while SQLite still counted it as held.
Another process could then release, reap or claim the controller exclusively.

An existing lock file is now only lstat'ed; a missing one is created with
O_EXCL, so the descriptor closed there is on a new inode that nobody locks.
SQLite opens the file with mode=rw and can no longer create it with 0644.
Ports browser_pool.py (pool/registry.ts, pool/operator.ts),
browser_preferences.py (pool/preferences.ts) and browser_start.py
(pool/start.ts, pool/provision.ts, pool/cua-cli.ts) with their tests:
shared and exclusive leases, the cookie-site sharing rule with
FAST_CHROME_UNSHARED_SITES, controller and tenant limits, pins,
crash-retained markers, exact release, reap and reset, serialized startup
that never replays an unconfirmed launch, and lossless Preferences.

Provisioning writes a wrapper that execs process.execPath on the stable host
copy under the state root, and per-profile manifests that allow only the
isolated extension copy's fixed ID. Chrome for Testing starts through
cua-driver with self-activation suppressed and the key-injected stable
extension. `browser-control pool` reproduces the Python argparse CLI without
`migrate`.

The multi-process tests run real Node children on one private state root:
concurrent claims, same-site gates, pins racing releases, SIGKILLed holders
and a reap that excludes other reapers. tests/server/parity/pool.md maps all
81 Python tests (79 ported; migrate and the legacy wrapper are not ported).
install copies the native host to a versioned directory under the state
root, writes the user wrapper (exec process.execPath) and the user Chrome
manifest for com.opzero.chrome with the store and isolated extension IDs,
and never replaces a manifest that names another host without --force.
It checks the extension (a port of check-extension-installed), cua-driver
and Chrome for Testing, builds the clipboard guard through
buildClipboardGuard and verifies it, links the bundled skills to stable
copies, and prints MCP snippets for OpenCode, Claude Code and Codex.

doctor runs the same checks read-only with fixed messages. --smoke starts
the server with a temporary state root and a user socket that does not
exist, serves a loopback fixture with FAST_CHROME_ALLOW_LOOPBACK=1, runs
claim_browser, open_tab, one act_steps batch and the releases, then reaps
the temporary profile.

package.json drops "private". docs/server/INSTALL.md documents setup.
Tests run only in temporary directories, including a packed tarball, and
fail if a default path under the real home directory changes.
…tdown deadlines exact

Load bare builtins that bundled dependencies require as node: builtins, re-arm deadline timers that fire
just before the monotonic deadline, ignore stdout write errors after the client closed its end, and wait
for the native host's final socket mode in the endpoint-permission test.
… path modules

browser-control mcp runs the real server. The packed-tarball test now
requires its 18 tools, the instructions, and status and tabs answers over
stdio, both with the fallback session and with a metadata session.

install, doctor and the smoke check reuse the server's modules instead of
their own copies: stableHostPlan, publishTree and treeMatches from
stable-copy.ts, the manifest name, isolated origin and socket limit from
pool/provision.ts, BUNDLE from pool/start.ts, metadata from
pool/registry.ts, guardianTrusted from private/clipboard-guard.ts,
runProcess from pool/cua-cli.ts, and the SDK's StdioClientTransport.

install and doctor have no default skills directory, because the old
default was inside an agent client's configuration (C4). They link and
check skills only in each --skills-dir. The package also ships the
onepassword-session skill, which browser-control links to, the
create-verification-skill skill, and docs/server/INSTALL.md.

Test fixtures use synthetic sites, emails and group titles in place of
organization and pool-account names. python-urls.json was regenerated
from the reference with CAPTURE_ONLY=urls; it equals the previous capture
with the names substituted. references.test.ts keeps these names, user
home paths, Node managers and agent client configuration paths out of
src/server, tests/server, docs/server and the shipped skills.
Keep the npm server packaging, remove the environment shim, and align doctor with the renamed host settings. Refresh the README and run disposable Chrome coverage in CI.
…ty completely

- Keep the stdin and SIGTERM listeners until exit, so the parent's SIGTERM that follows EOF during cleanup no
  longer kills the process before cleanup ends. A subprocess test sends EOF, then SIGTERM 2 s later while
  finalizeTabs is still being answered, and requires exit 0 with the finalization read back.
- Serialize stable-copy publication with <parent>/.publish.lock and check the target again under the lock, so
  a valid copy another process published is never deleted. A damaged copy is moved aside with one rename.
  Tests race six publisher processes and wait on another process's lock.
- Keep integer and float literals apart at the native-host boundary (isPyInt), so protocolVersion 2.0, a
  response id 3.0, an error code -32000.0, a tab id 5.0, a pageProtocolVersion 2.0 and a submit lifetime
  90000.0 are refused as Python refused them.
- Run check-parity with --complete in pnpm run check. Accept "not ported" only for the approved removals,
  ignore skipped and todo titles, and require it.each for parametrized Python tests except the listed three.
- State ~/.opzero-chrome/default.sock as the one path outside the state root, and record the approved
  deviations (D19/Q3, Q1, D6, D10) and follow-ups F1-F3 in DESIGN.md.
… root, and install the zip host as a stable copy

Python's stdin reader has no size bound; the SDK-style 10 MiB cap closed the transport without starting the
bounded shutdown. A stdin read error now closes the transport, and Server.onclose begins the same Shutdown as
EOF and SIGTERM.

The user route, the install wrapper and the bundled native host default to <state>/sockets/user.sock, so two
state roots never share an endpoint and nothing is created under ~/.opzero-chrome (C4, D1).

The release zip's install-native-host script publishes the host and its chunks under <state>/hosts/, writes a
wrapper with single-quoted literals that execs process.execPath, and refuses to replace a manifest that names
another host without --force.

Registry pids and cua-driver CLI pids and window IDs refuse integral float literals, as type() is int did (D20).
claim_browser receipts carry controller_id and lease_id but no owner. The
skill told agents to record the owner from the receipt, which is impossible.
pool status lists every lease with its owner, so the operator matches the
receipt's lease_id there and passes that owner to pool release.
…tallers

The release zip's install-native-host.js rewrote scripts/extension-id.json in
the directory it ran from. Run from the stable skill copy that
'browser-control install --skills-dir' publishes, that changed the
content-addressed copy, and doctor then reported the skill as stale. The only
readers are the two check scripts' fallback, which the build already fills with
the store ID, so the installer no longer writes the file. An unpacked ID goes
to the checks with --extension-id or BROWSER_CONTROL_EXTENSION_ID.

native-host.md and INSTALL.md now say which installer owns the
com.opzero.chrome manifest (the last one run; neither replaces the other
without --force), which socket each host uses (the zip's
~/.opzero-chrome/default.sock, the package's <state>/sockets/user.sock), how
the server's user route picks its socket, and how doctor reports the zip
installer's manifest (manifest foreign, previous = hosts/skill wrapper).
… need

store/ and site/ describe the 0.2.2 installer, which is deployed and under
review, so they stay unchanged. RELEASE.md now lists what changed since then
(stable copy under the state root, process.execPath, the --force conflict
check, no writes into the unzipped folder) and what to refresh and re-run
before the next release.
Both workflows run pnpm run check, which builds and tests the MCP server. The
server needs node:sqlite, so package.json requires Node 24; check.yml already
used it, while release.yml and chrome-web-store.yml still set up Node 22.
Under a loaded full suite, two tests failed in 3 of 6 check runs:

- host.test.ts connected as soon as the socket file existed. bind() creates
  the file before listen(), so a preempted host refused the connection. The
  harness now also waits for the host to remove <socket>.lock, which it does
  once it is listening.
- cua-mcp.test.ts gave the hung-call test a 1.5 s read deadline, but the path
  to the hung clipboard_read takes about 2 s under load (the full read in the
  test before it took 1.6-2.0 s), so the deadline expired before the hang.
  The budget is now 4 s and the elapsed bound 6.5 s.

Neither product code nor the tests' assertions changed. Both files then passed
12 of 12 runs with four suites in parallel, and pnpm run check passed 3 of 3.
The merge of origin/main, the orphaned-lease recovery through pool status,
the installer that no longer writes into its own directory, the two-installer
documentation, Node 24 in the release workflows and the two test fixes.
The release zip's installer checked its host copy before staging, then moved
any copy at the target aside. Two installers could both find it absent; one
published, and the next moved that valid copy away. Chrome failed in the gap,
a SIGKILL between the two renames left the wrapper on a missing directory, and
a failed second rename deleted the displaced copy. Separately, both installers
classified the Chrome manifest and replaced it later, so two installers could
both find it absent and both report success, bypassing the --force refusal.

src/shared/install-lock.ts is a lock both installers can use: the zip
installer runs on Node 18, which has no node:sqlite. A lock is a directory
holding one <pid>-<uuid> entry, taken by renaming a directory that already
holds the entry into place. A waiter removes an entry only when kill(pid, 0)
reports ESRCH, and gives up after 10 s naming the lock and its holder.

- The zip installer publishes under <state>/hosts/.skill-publish.lock and
  checks the target again under it. A copy is moved aside only after it was
  read and differs, is put back if the new copy cannot be renamed in, and is
  deleted only once the new copy is in place.
- Both installers take <manifest dir>/.com.opzero.chrome.json.lock, classify
  the manifest again under it, and only then replace it. The unlocked check
  stays first, so refusals, dry runs and current manifests write nothing.
  install reports a lock that stays held as manifest: locked.

tests/acceptance/installer-races.test.ts runs the built installers as real
processes. Against the previous installers, 6 and then 5 of its 10 tests
failed in two runs: concurrent zip installs displaced the published copy, and
both installers succeeded for one manifest. The test that kills installers
mid-run failed in one of the two runs, depending on where the kills landed.
…octor

install writes BROWSER_CONTROL_HOST_SOCKET into the user wrapper, and the
docs tell users to give the server the same socket, but the snippets that
install and config print carried only BROWSER_CONTROL_STATE_DIR. They now
also set BROWSER_CONTROL_HOST_SOCKET when it differs from the state
directory's <state>/sockets/user.sock (D1).

When the wrapper differs from the expected one only in the socket it exports,
doctor reported it as stale: "does not run this version's host with this
Node.js". It now reports wrapper: socket-mismatch, with previous set to the
wrapper's socket and a message that names the socket. Any other difference is
still stale.
browser-pool.md said "the CLI also stops an idle controller's Chrome" without
naming the command. It now names pool reap [controller] [--dry-run], lists
it with the other operator commands, and says that without a controller it
tries each one. Checked against dist/server/cli.js in a temporary state root:
pool --help lists reap, pool reap isolated-1 --dry-run prints dry_run: true,
and pool reap --dry-run prints one result per controller.
The shared installer lock as the one exception to section 4.4's rejected
lock alternatives, the zip installer's serialized publication, the manifest
lock both installers take, the socket in the printed configuration, doctor's
socket-mismatch status, the pool reap reference, and the residual risks.
The shared installer lock trusted its path. A lock path that was a symlink
was listed and cleaned through: the entries of dead processes in the
directory it pointed at were deleted. Neither the lock nor its parent was
checked for owner, mode or identity, so in a parent that others can write to
without the sticky bit, another user could rename a held lock aside and let a
second installer in while the first was still in its critical section.

Before any cleanup or acquisition, the lock now checks:

- The parent (followed if it is a symlink) is a directory owned by this user
  that group and others cannot write to, unless it has the sticky bit.
- The lock path, by lstat, is missing or a real directory owned by this user
  that group and others cannot write to. A symlink or anything else is
  refused.

A refusal is InstallLockUnsafe, with a fixed message that names the path at
fault. browser-control install reports it as manifest: unsafe-lock with that
path and code browser-controller-unsafe-install-lock.

As in src/server/fs-private.ts, the lock directory is a path plus its
(dev, ino). The identity is recorded when the directory is made, or when it is
first checked, and verified again before each stale entry is removed, before
the emptied directory is removed, and at release. A cleanup that finds the
path replaced removes nothing, and the next attempt checks the new directory.
A release that finds it replaced removes nothing and throws. Only regular
files named <pid>-<uuid> are removed.

Both installers now create a missing manifest directory with mode 0755. Under
a group-writable umask the directory would otherwise be 0775, and the new
check would refuse a directory the installer had just made.

The checks are local to src/shared/install-lock.ts and use only Node 18 APIs,
so the zip installer bundle gains no chunk and no node:sqlite. The built zip
installer ran under Node 18.20.8 and refused a 0777 manifest directory and a
symlinked lock, and it cleared a dead process's lock.

Against the previous lock, 15 of the 18 new tests in installer-races.test.ts
failed, and so did the new install.test.ts test. The previous lock deleted the
file outside its directory through the symlink and accepted a 0777 parent.
The three tests that take the lock in a 0700, 0755 or 01777 parent passed
before and after. The stale-cleanup interleaving between two waiters is
driven by an injected readdir, not by sleeps, and exactly one waiter takes
the lock.
A 1 ms wait deadline can allow a second poll, which ran out of scripted
responses and failed the test about once in four full runs. The test now
scripts enough Loading pages; its assertions are unchanged.
The lock checked only its immediate parent, and it followed that parent if
it was a symlink. It also removed its staging directory recursively through
its path. Suppose another uid can write to a directory above the parent. It
can rename the parent aside, rename one of the victim's private directories
to the staging name, and replace the parent with a symlink. The recursive
removal in the finally block then deleted the victim's directory.

Trusted ancestors: the lock resolves its parent with realpath and lstats
every directory from / down to it. Each must be a directory owned by the
current uid or by root, and must not be group- or world-writable unless it
has the sticky bit (OpenSSH's safe_path rule). If one fails, InstallLockUnsafe
names the first directory at fault, and nothing is created. The acquisition
then works only in the resolved path. The parent's dev and ino are verified
again around each mutation and at release. A replaced parent throws
InstallLockUnsafe, and nothing is removed.

Bounded cleanup: the lock code no longer removes anything recursively. The
identities of the staging directory and its one <pid>-<uuid> entry are
recorded when they are made. Cleanup unlinks the entry and then rmdirs the
directory, each only while it still matches. On a mismatch it removes
nothing more, and rmdir leaves a directory that holds anything else. Release
removes its entry and the lock directory the same way.

The zip installer publishes in the real hosts directory that its publish
lock checked. The files it wrote are removed one at a time, each checked by
identity. A damaged copy is moved into the installer's own staging directory.
It is removed recursively, and only once the new copy is in place and both
directories have been verified. That is the one recursive removal left in
src/shared and src/scripts. replaceFile removes its temporary file only when
the rename fails.

Tests inject readdir and lstat, and none of them sleeps. They cover the
audit's substitution and a directory renamed to the staging name; in both,
the substituted directory survives. They also cover a staging directory with
another entry in it, which is left; a writable ancestor, which is refused
unless it has the sticky bit; an ancestor owned by another uid, which is
refused, or by root, which is accepted; and a symlink, which is resolved,
after which exactly the resolved chain is checked. Against the previous lock,
15 of the 39 tests in the file fail. The built zip installer ran on Node
18.20.8.
Both installers took the manifest lock in the real directory it checked,
but then classified and wrote the manifest through the path they were
given. Suppose another uid owns a symlink in that path. After the
installer found no manifest in A, that uid could point the symlink at B.
The temporary manifest was then made in B and renamed over B's foreign
manifest, without --force.

Now both installers classify, write, rename and clean up
<lock.directory.path>/com.opzero.chrome.json. Just before the rename,
stillResolves checks that the locked directory keeps its dev and ino and
that the given directory still resolves to it. If not, only the temporary
file is removed, and the installer refuses. The zip installer exits 1, and
browser-control install reports manifest: fail / moved.

The zip installer recorded the state root as given. Suppose a directory
above it was another uid's symlink into a trusted tree. Repointing that
symlink after the install made Chrome run that uid's wrapper. A matching
host copy also skipped the publish lock, the only place the ancestor rule
ran. The installer now resolves the state root with checkDirectory on
every run and requires it to be private. It makes hosts and hosts/skill
in the resolved root. The wrapper, the host it execs and manifest.path are
all under that real path.

browser-control install already refused a symlink anywhere in the state
root, through fs-private's walk. That walk checks owner and mode only on
the last directory, so stateStep now applies the same ancestor rule and
reports state: fail / unsafe-ancestor.

Tests: 9 new. Against 25dc747, 8 of them fail. The retargeting tests fail
because B's manifest is overwritten. The ELOOP test records the existing
fs-private refusal. Three distribution tests now expect real paths under
macOS /var. The built zip installer ran on Node 18.20.8.
…rver

The checks covered only the resolved path, or only the socket's own
directory. Suppose another uid owns a symlink, or a directory that holds
one, somewhere in the path given. The installers accepted a manifest
directory reached through a symlink in a 0777 directory, and npm's
current-manifest fast path and dry run skipped even the resolved check.
The host checked its socket's directory and then kept using the path it
was given. Its startup lock wrote <socket>.lock.<pid> with writeFileSync,
which follows a symlink and truncates. The server checked ownership and
then connected through the same mutable path.

src/shared/trusted-path.ts walks a path as given, one component at a time
from /, as the kernel resolves it. Every directory, the last one included,
must be owned by the uid or root and not group- or world-writable unless
it is sticky. A symlink is followed only when its directory passed and the
symlink is the uid's or root's; its target is walked the same way, through
at most 16 symlinks. The result is the canonical path and the directory's
dev and ino. macOS /var and /tmp pass. It uses only fs and path, so the zip
installer and the host still run on Node 18.

The install lock, the state root and hosts, and the manifest directory in
both installers now use it, on every path through the code: npm's fast
path, the dry run and each unlocked pre-check. Missing directories are
made only inside directories that passed. The lock, classification, write
and rename use the canonical directory. The host requires a private
canonical socket directory and uses the canonical path for its lock, bind,
recovery and cleanup. It creates the staged lock with O_CREAT | O_EXCL |
O_NOFOLLOW, so a planted symlink refuses the startup. Connection.open
checks and connects through the canonical path. The wrapper, the snippets
and doctor export the canonical socket (D21 records the parity change).

Tests: 24 new, 4 changed. Against b763a84 with them copied in, 12 fail:
the old host overwrote the file behind a planted symlink and bound in a
repointed tree, the client reached the other host, and both installers
wrote through a symlink in a 0770 or 0777 directory. The built zip
installer and host ran on Node 18.20.8: a fresh install, the host started
through its wrapper, and host.ping answered over its socket.
The skill's clients were outside the trusted-path rule. client.ts
connected with no check. transport.ts checked only the socket's own
directory and then connected through the path it was given, so a symlink
higher up could be repointed between the check and the connect. The
handshake does not authenticate the host, and privateFill sends values
after it. privateSocketEndpoint in src/shared/trusted-path.ts is now the
one check for the server's Connection.open, client.ts and transport.ts.
The socket's directory must pass trustedPath and be private, the endpoint
must be the uid's socket, and all three connect only to its canonical
path. TCP, the argv rules and the stopped-host message are unchanged.

trustedPath's missing mode joined the rest of the path as text, so
/tmp/gap/../attacker/hosts came back as an unchecked /tmp/attacker/hosts,
where npm found a current manifest and stopped. A `..` after a missing
directory now fails with ENOENT, as the kernel does. Neither installer
reads through a missing result: the manifest is absent until the
directory is made and locked.

Both installers classified a manifest by its type and bytes, so another
uid could plant a byte-identical one in a 01777 directory, and npm
reported it unchanged even with --force. src/shared/manifest-file.ts
trusts only the uid's regular file that group and others cannot write.
Anything else is untrusted: refused without --force, and with --force
replaced under the lock. Another uid's file in a sticky directory that
is not the uid's is refused as cannot-replace. Doctor reports untrusted.

Tests: 29 new, 3 changed. Against 7f8721f with them copied in, 20 fail.
The old client and transport reached the other host through the
repointed symlink. The walk returned the gap path, npm reported unchanged
for it and for every untrusted manifest, and the zip installer read the
attacker's manifest and replaced untrusted ones without --force. The
built skill ran on Node 18.20.8 against a real host under a temporary
HOME: client.js ping and a transport open and close worked over the
default socket and <state>/sockets/user.sock, and the refusals held.
The state root, the artifact root, each --skills-dir, the manifest
directory and every socket path now pass the trusted-path rule where
they enter: mcp, pool, config, install, doctor, the zip installer, the
host, client.js and transport.js. A root that fails is refused with a
fixed message that names it and the directory at fault.

- fs-private applies the rule to every ancestor on each walk, so the
  state root's ancestry is checked on every use, not only at install.
- Skill links count as current only when the uid owns them. Another
  user's link is untrusted without --force, and --force replaces it
  only where rename allows.
- Screenshots and recordings are made only in the canonical path of a
  private root (privateDirectory).
- The pool probes and removes a stale socket only through
  privateSocket's canonical path, and unlinks it only while its
  identity is unchanged.
- DESIGN.md adds a filesystem operation inventory: 107 call sites,
  each with its root and protection.
linkSkill cleaned up a failed rename through removeCreated, which refuses
every symlink, so .<name>.<uuid>.tmp stayed in the skills directory.
createdLink now records the link's own lstat identity when it is made.
removeCreatedLink unlinks it only while it sits directly in the checked
directory, the directory keeps its identity, and lstat still finds a
symlink with that identity. unlink never follows it, and any other
entry, including a symlink swapped in, is left. removeCreated still
refuses every symlink.

fsyncDirectory now opens with O_NOFOLLOW, so a symlink put in the
verified directory's place is not followed.

DESIGN.md describes the code exactly: childDirectory does not repeat
the walk, doctor --smoke checks the real path of TMPDIR, the host's
startup lock and reset's profile are not checked for mode, a skills
directory need not be 0755, and reused executables are checked at the
final file only.
The host binds its socket before the extension finishes the protocol
handshake, so on a slower runner host.info could still report a pending
extension and Connection.open refused with browser-control-protocol-mismatch.
The test now retries the connection until the host reports ready.
expect.poll also retries thrown errors, so the connection filter was not
enforced. An explicit deadline loop now retries only browser-control-unavailable
and browser-control-protocol-mismatch and rethrows anything else at once.
@afif-reap
afif-reap merged commit a39589d into main Sep 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant